Hammerspoon LLM script

  • 2025-04-17 (modified: 2025-10-16)

텍스트를 선택하고 단축키를 누르면 해당 텍스트를 이용하여 OpenAI API를 호출하는 Hammerspoon 스크립트. AI-인간 상호작용 루프에서의 병목을 조금이라도 줄여보려는 시도 중 하나.

macOS “Writing Tools”와의 차이

이미 macOS의 “Writing Tools”에 유사한 기능이 있긴 하지만, Slack, VSCode, Obsidian 등 WebView 기반의 앱 또는 네이티브 앱이라도 텍스트 컨트롤을 직접 구현한 앱(예: zed)에서는 작동하지 않는 문제, 내 마음대로 기능이나 프롬프트를 다듬을 수 없는 문제 등 단점이 있다.

사용법

  1. 아무 텍스트나 선택한다.
  2. 단축키(Hammerspoon에서 임의로 설정)를 누른다.
  3. 명령을 입력한다.
  4. 클립보드에 LLM의 응답이 담긴다.

쓸 수 있는 명령들은 다음과 같다.

  • ko: 텍스트를 한국어로 번역한다.
  • en: 텍스트를 영어로 번역한다.
  • cli: 텍스트에 적힌 내용을 실행하는 맥OS CLI 명령을 작성한다.
  • regex: 텍스트에 적힌 내용을 정규표현식으로 작성한다.
  • sum: 텍스트를 짧게 요약한다.
  • explain: 텍스트에 담긴 내용(코드 등)을 설명한다.
  • answer: 텍스트에 담긴 내용에 대답한다.
  • enhance: 텍스트를 최소한으로 수정하여 개선한다.
  • pr: 텍스트를 교정한다.
  • bullet: 텍스트를 총알 목록으로 변환한다.
  • dic: 해당 단어나 구를 설명한다.

명령을 여러 개 입력하면 유닉스 파이프라인처럼 연결된다. 예를 들어 summarize bullet ko라고 입력하면 요약한 뒤 불릿 리스트로 변환한 다음에 한국어로 번역한다.

어떤 병목을 줄이나

인간의 귀찮음 또는 게으름으로 인한 병목을 조금 줄여줄 수 있다.

예를 들어 지금은 컴퓨터 사용 중 AI를 써서 어떤 텍스트를 요약한 뒤 한국어로 번역하고 그 결과를 클립보드에 복사하려면 다음 단계가 필요하다.

  1. 텍스트를 선택한다.
  2. 텍스트를 복사한다.
  3. 단축키를 눌러서 ChatGPT 입력창을 연다. (ChatGPT 앱을 설치하지 않았다면 브라우저로 전환한 뒤 새 탭을 열고 ChatGPT에 사이트에 접속한다)
  4. 텍스트를 붙여넣는다.
  5. 추가로 프롬프트를 입력한다. 예: “Summarize this text and translate the summary to Korean.”
  6. 결과를 복사한다.

이 스크립트를 쓰면 아래와 같이 단순해진다.

  1. 텍스트를 선택한다.
  2. 단축키를 누른다.
  3. 프롬프트를 입력한다. 예: “sum ko”

코드

api_key, endpoint, model_id를 적절히 바꾸면 OpenAI 뿐 아니라 OpenAI Completion API와 호환되는 다양한 제공자(OpenRouter, Gemini 등)를 쓸 수 있다. 아래 코드는 Anthropic의 Haiku 4.5를 사용한다.

function obj:buildSystemPrompt(cmds)
    local lines = {}
    for word in cmds:gmatch("%S+") do
        local description = COMMAND_DESCRIPTIONS[word]
        if description then
            table.insert(lines, "- " .. description)
        end
    end
    return string.format(SYSTEM_PROMPT_BASE, table.concat(lines, "\n"))
end

function obj:init()
    return self
end

function obj:setStatus(emoji)
    self.statusIcon = hs.menubar.new()
    self.statusIcon:setTitle(emoji)
end

function obj:clearStatus()
    self.statusIcon:delete()
    self.statusIcon = nil
end

function obj:bindHotkeys(mapping)
    local spec = {
        generateText = hs.fnutils.partial(self.generateText, self),
    }
    hs.spoons.bindHotkeysToSpec(spec, mapping)
    return self
end

function obj:generateText()
    hs.eventtap.keyStroke({ "cmd" }, "c")
    hs.timer.usleep(0.05)

    local focusedApp = hs.window.frontmostWindow():application()
    local body, cmds = self:getBodyAndCmds()
    hs.timer.doAfter(0.01, function() focusedApp:activate() end)

    if not body then
        return
    end

    local header = {
        ["Content-Type"] = "application/json",
        ["Authorization"] = "Bearer " .. self.api_key,
    }
    local reqBody = {
        model = self.model_id,
        messages = {
            { role = "system", content = self:buildSystemPrompt(cmds) },
            { role = "user",   content = body .. '\n\n' .. cmds },
        },
        max_completion_tokens = 10240,
    }

    self:setStatus("")
    hs.http.asyncPost(
        self.endpoint,
        hs.json.encode(reqBody),
        header,
        function(http_code, res)
            self:clearStatus()
            local resBody = hs.json.decode(res)
            if http_code ~= 200 then
                hs.alert("Failed to get response: " .. resBody.error.message)
                return
            end

            local resText = resBody.choices[1].message.content
            hs.pasteboard.setContents(resText)
            hs.alert("Updated clipboard")
        end
    )
end

function obj:getBodyAndCmds()
    local body = hs.pasteboard.getContents()
    if not body then
        hs.alert("Please select the text first")
        return nil
    end

    local button, cmds = hs.dialog.textPrompt("Enter the command", "", "ko summarize", "Ok", "Cancel")
    if button == "Cancel" or not cmds or cmds == "" then
        return nil
    end

    for word in cmds:gmatch("%S+") do
        if not COMMAND_DESCRIPTIONS[word] then
            return nil
        end
    end

    return body, cmds
end

return obj